Skip to content

perf: Cache Edwards public key for XEdDSA signatures (-27% signing, with lazy key gen) - #240

Merged
jlucaso1 merged 8 commits into
mainfrom
perf-experimental-cache-curve
Jan 24, 2026
Merged

perf: Cache Edwards public key for XEdDSA signatures (-27% signing, with lazy key gen)#240
jlucaso1 merged 8 commits into
mainfrom
perf-experimental-cache-curve

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jan 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR optimizes XEdDSA signature creation by caching the Edwards public key point, avoiding an expensive scalar multiplication on every signature call.

As noted in the XEdDSA specification:

"Calling calculate_key_pair for every XEdDSA signature roughly doubles signing time compared to EdDSA, since calculate_key_pair performs an additional scalar multiplication E = kB. To avoid this cost signers may cache the (non-secret) point E."

Changes

  • Added XEDDSA_HASH_PREFIX static constant (minor optimization)
  • Cache ed_public_key (CompressedEdwardsY) and sign_bit using lazy initialization via OnceLock
  • Added from_bytes_with_cache() and new_without_cache() constructors for flexible key creation
  • Updated signature functions to use cached values instead of recomputing
  • Key generation uses lazy initialization - Edwards cache computed on first signature, not at key creation

Bencher CI Benchmark Results

Measured with iai-callgrind in isolated CI environment (instruction count):

Key Improvements

Benchmark Change Instructions
Signature Creation -26.78% 4,722,091 → 3,457,597
Marshal (allocating) -13.32% 143,804 → 124,647
Marshal (reusing buffer) -13.31% 143,905 → 124,747
Unmarshal small -6.16% 3,224 → 3,025
Attr parser -4.95% 7,397 → 7,031

Key Generation (Lazy Initialization)

Benchmark Change Notes
Key Generation +0.02% No regression - lazy init works
Group Create Distribution +0.04% No regression - lazy init works

Real-World Impact by Use Case

Scenario Signing Pattern Benefit
Group messaging 1 signature per message sent (SenderKey) High - 1.37x faster
Active group user 100+ signatures/day High
Session establishment Signatures during X3DH handshake Moderate
1-to-1 messaging Uses HMAC, not ED25519 None (no signatures)
Device setup 2-5 signatures total Negligible

Design: Lazy Initialization

Unlike the original approach that eagerly computed the Edwards cache at key creation (causing 2x key generation overhead), this implementation uses OnceLock for lazy initialization:

  • Key generation: Fast (no Edwards computation)
  • First signature: Computes and caches Edwards public key
  • Subsequent signatures: Uses cached value (no scalar multiplication)

This gives the best of both worlds - fast key generation AND fast signing.


Further Optimization Opportunities

Analysis of the libsignal crate revealed additional optimization opportunities for future PRs:

High Priority

Area File Issue Potential Impact
Hash Finalization crypto/hash.rs finalize() always allocates Vec even when fixed-size array suffices Medium - hot path
PublicKey Deserialize protocol/state/session.rs:238 Repeated deserialization in receiver chain loops Medium-High
Protobuf Clone protocol/state/session.rs:254 Cloning large Chain structs in get_receiver_chain() Medium

Already Well-Optimized ✓

Area File Pattern
Thread-local buffers session_cipher.rs, group_cipher.rs Reusable buffers with smart shrinking
AES-CBC encryption crypto/aes_cbc.rs In-place encryption, pre-allocated output
HMAC key reuse protocol/ratchet/keys.rs finalize_reset() to avoid recreating HMAC
MessageKey lazy eval protocol/ratchet/keys.rs Zero-cost protobuf round-trip with MessageKeyGenerator enum
Signature verification curve25519.rs Uses vartime_double_scalar_mul_basepoint + ct_eq

Test plan

  • All existing tests pass (cargo test --all)
  • Clippy clean (cargo clippy --all-targets)
  • Bencher CI benchmarks show expected improvements

Summary by CodeRabbit

  • New Features

    • Added a benchmarking suite for cryptographic operations.
  • Performance Improvements

    • Faster signing via cached curve data to avoid repeated heavy computations.
    • More efficient hash finalization across file and message processing.
    • Streamlined session lookup and previous-session handling for quicker state access and decryption.
  • Tests

    • Added tests covering lazy caching, serialization/deserialization, cloning, multi-signature scenarios, and key agreement.
  • Chores

    • Adjusted ownership/clone usage to prevent unintended moves and removed implicit copy semantics.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai

coderabbitai Bot commented Jan 24, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Caches Edwards public key and sign bit inside private-key representations to avoid repeated scalar multiplications; updates signing, key generation, and serialization to use cached values. Replaces several hash finalizations with zero-allocation helpers, removes one SessionState accessor, and converts multiple ownership moves into explicit clones.

Changes

Cohort / File(s) Summary
Benchmarks
wacore/libsignal/benches/libsignal_benchmark.rs
Return a cloned identity key pair in benchmark setup to avoid moving ownership.
Core curve (lazy Edwards cache & signing)
wacore/libsignal/src/core/curve.rs, wacore/libsignal/src/core/curve/curve25519.rs
Add EdwardsCacheData and OnceLock-based lazy caching; change PrivateKeyData::DjbPrivateKey to include cache; add compute/cache helpers, from_bytes_with_cache/from_bytes_without_cache, cached accessors, and XEdDSA hash prefix; update key generation, serialization, public-key derivation, signature and agreement paths; add tests.
Session state lookup
wacore/libsignal/src/protocol/state/session.rs
Remove public get_receiver_chain(...); optimize receiver-chain lookup by comparing serialized public-key bytes (validate length/type byte first) to avoid deserialization; add helper to find previous-session index.
Session / previous-session promotion & cipher
wacore/libsignal/src/protocol/session_cipher.rs, wacore/libsignal/src/protocol/session.rs
Replace clone-based promotion with take/restore and index-based promotion; iterate previous sessions via take/restore, promote immediately on success, and restore on errors; save base public key before moving base key pair.
Zero-allocation hash finalization
wacore/src/messages.rs, wacore/src/upload.rs
Replace finalize()/HMAC finalize usages with finalize_sha256_array() zero-allocation finalizers; remove intermediate buffers and adjust error mapping/comments.
Ownership / clone adjustments
src/handshake.rs, src/pair.rs, src/store/signal.rs, wacore/libsignal/src/protocol/identity_key.rs, wacore/libsignal/src/protocol/ratchet.rs, wacore/src/store/device.rs
Replace value moves with .clone() for identity/noise/private keys; remove Copy derive from IdentityKeyPair (now Clone only); update call sites to preserve ownership.
Misc / Manifest
Cargo.toml (manifest edits)
Manifest/metadata lines adjusted to reflect core changes and new tests.

Sequence Diagram(s)

(Skipped)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I cached a point where scalars used to hop,
Saved work and nibble cycles — never stop.
Hashes crisp and clones snug as a sock,
Sessions leap forward — tick-tock, tick-tock. 🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'perf: Cache Edwards public key for XEdDSA signatures (-27% signing, with lazy key gen)' directly and clearly describes the main performance optimization in the PR—caching Edwards public keys for XEdDSA signatures with lazy initialization.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jan 24, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchperf-experimental-cache-curve
Testbedubuntu-latest

🚨 1 Alert

BenchmarkMeasure
Units
ViewBenchmark Result
(Result Δ%)
Upper Boundary
(Limit %)
binary_benchmark::unpack_group::bench_unpack_compressedInstructions
instructions x 1e3
📈 plot
🚷 threshold
🚨 alert (🔔)
556.53 x 1e3
(+14.30%)Baseline: 486.88 x 1e3
511.23 x 1e3
(108.86%)

Click to view all benchmark results
BenchmarkInstructionsBenchmark Result
instructions
(Result Δ%)
Upper Boundary
instructions
(Limit %)
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled()📈 view plot
🚷 view threshold
7,031.00
(-4.86%)Baseline: 7,390.37
7,759.88
(90.61%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
124,647.00
(-13.10%)Baseline: 143,435.92
150,607.72
(82.76%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
124,747.00
(-13.09%)Baseline: 143,536.87
150,713.71
(82.77%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
106,274.00
(0.00%)Baseline: 106,274.00
111,587.70
(95.24%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,487.00
(0.00%)Baseline: 8,487.00
8,911.35
(95.24%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
49,338.00
(-0.41%)Baseline: 49,540.71
52,017.75
(94.85%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
3,025.00
(-6.05%)Baseline: 3,219.75
3,380.74
(89.48%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
🚨 view alert (🔔)
556,531.00
(+14.30%)Baseline: 486,882.90
511,227.05
(108.86%)

binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
779.00
(+0.02%)Baseline: 778.87
817.81
(95.25%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,828,688.00
(+0.25%)Baseline: 27,759,698.71
29,147,683.65
(95.47%)
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message()📈 view plot
🚷 view threshold
5,569,753.00
(-0.00%)Baseline: 5,569,783.00
5,848,272.15
(95.24%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
178,014.00
(+0.24%)Baseline: 177,585.67
186,464.95
(95.47%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
178,830.00
(+0.25%)Baseline: 178,392.62
187,312.25
(95.47%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,266,027.00
(-0.01%)Baseline: 17,268,262.90
18,131,676.05
(95.23%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
295,795.00
(+0.06%)Baseline: 295,623.76
310,404.95
(95.29%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,512,827.00
(-0.76%)Baseline: 12,608,074.67
13,238,478.40
(94.52%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
717,058.00
(+0.16%)Baseline: 715,906.00
751,701.30
(95.39%)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()📈 view plot
🚷 view threshold
41,604.00
(+0.39%)Baseline: 41,443.00
43,515.15
(95.61%)
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages()📈 view plot
🚷 view threshold
5,530,585.00
(-0.51%)Baseline: 5,558,744.50
5,836,681.72
(94.76%)
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session()📈 view plot
🚷 view threshold
965,258.00
(-1.51%)Baseline: 980,025.00
1,029,026.25
(93.80%)
libsignal_benchmark::signature_group::bench_key_generation keygen📈 view plot
🚷 view threshold
2,823,163.00
(+0.02%)Baseline: 2,822,462.33
2,963,585.45
(95.26%)
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message()📈 view plot
🚷 view threshold
3,457,597.00
(-32.78%)Baseline: 5,143,589.00
5,400,768.45
(64.02%)
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message()📈 view plot
🚷 view threshold
125,823,269.00
(-0.16%)Baseline: 126,024,575.00
132,325,803.75
(95.09%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
11,819.00
(+0.07%)Baseline: 11,810.98
12,401.53
(95.30%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,837.00
(+0.18%)Baseline: 3,830.06
4,021.56
(95.41%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
88,033.00
(+0.03%)Baseline: 88,005.71
92,406.00
(95.27%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
80,074.00
(+0.03%)Baseline: 80,047.79
84,050.18
(95.27%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
51,035.00
(0.00%)Baseline: 51,035.00
53,586.75
(95.24%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,738.00
(+0.14%)Baseline: 5,729.98
6,016.48
(95.37%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
2,121.00
(+0.33%)Baseline: 2,114.06
2,219.76
(95.55%)
reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,921.00
(+0.08%)Baseline: 21,904.27
22,999.48
(95.31%)
🐰 View full continuous benchmarking report in Bencher

@jlucaso1
jlucaso1 force-pushed the perf-experimental-cache-curve branch from 289041b to 799fd6d Compare January 24, 2026 12:38

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@wacore/libsignal/src/protocol/state/session.rs`:
- Around line 227-247: The fast-path in get_receiver_chain_index currently
compares raw sender bytes to stored chain.sender_ratchet_key bytes without
validating format, which can hide corrupted keys and later cause panics in
set_message_keys / set_receiver_chain_key; add a cheap validation gate before
the byte-comparison that verifies the stored sender_ratchet_key has the expected
serialized layout (e.g., correct type/version byte and length matching
PublicKey's serialized size or the constant used by PublicKey::serialize),
returning Err(InvalidSessionError("invalid receiver chain ratchet key")) on
failure; keep the direct byte comparison afterwards to preserve the optimization
while restoring the original validation semantics for sender_ratchet_key.
🧹 Nitpick comments (1)
wacore/libsignal/src/core/curve/curve25519.rs (1)

70-83: Mask sign_bit to avoid invalid signatures from bad inputs.

If a caller passes a value other than 0x00 or 0x80, the signature encoding can be corrupted. A small mask keeps the API safe.

♻️ Suggested hardening
-        PrivateKey {
-            secret,
-            ed_public_key,
-            sign_bit,
-        }
+        PrivateKey {
+            secret,
+            ed_public_key,
+            sign_bit: sign_bit & 0b1000_0000_u8,
+        }

Comment thread wacore/libsignal/src/protocol/state/session.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@wacore/libsignal/src/core/curve.rs`:
- Around line 366-377: The generate function wastes work by creating temp via
curve25519::PrivateKey::new(csprng) which computes the Edwards cache, then
discarding it when building PrivateKeyData::DjbPrivateKey with edwards_cache:
OnceLock::new(); change generate to preserve temp's computed edwards cache into
the new PrivateKeyData (e.g., initialize the OnceLock with the computed cache or
transfer the cached representation from temp into edwards_cache) so the
precomputed cache from temp is reused; locate symbols generate,
curve25519::PrivateKey::new, temp, PrivateKeyData::DjbPrivateKey, and
edwards_cache/OnceLock to implement the transfer.
- Around line 216-227: PrivateKeyData cannot derive Clone because
OnceLock<EdwardsCacheData> isn't Clone; replace the edwards_cache field with a
cloneable sync wrapper such as Arc<Mutex<Option<EdwardsCacheData>>> (e.g.,
edwards_cache: Arc<Mutex<Option<EdwardsCacheData>>>), update any initialization
to Arc::new(Mutex::new(None)), and update code paths that lazily compute or read
the Edwards cache (where edwards_cache is accessed during signing) to lock the
mutex, check/set the Option, and return the cached value; this makes
PrivateKeyData::DjbPrivateKey cloneable while preserving the lazy-cache
semantics.

In `@wacore/libsignal/src/core/curve/curve25519.rs`:
- Around line 99-110: from_bytes_without_cache currently constructs a PrivateKey
with dummy ed_public_key and sign_bit=0 which can silently produce invalid
signatures if calculate_signature is called; update this by choosing a clear
sentinel (e.g., an out-of-range sign_bit value or a reserved ed_public_key
pattern) when constructing the PrivateKey in from_bytes_without_cache and then
make calculate_signature check that sentinel and return an explicit error or
panic instead of producing a signature; update the doc comment on
from_bytes_without_cache to warn about non-signing use and reference the
sentinel so future maintainers see the contract (symbols:
from_bytes_without_cache, PrivateKey, ed_public_key, sign_bit,
calculate_signature).
🧹 Nitpick comments (1)
wacore/libsignal/src/core/curve/curve25519.rs (1)

70-85: Consider adding debug assertion for cache consistency.

The from_bytes_with_cache constructor trusts that the provided ed_public_key and sign_bit match the private_key. While this is intentional for performance, a debug_assert! could help catch mismatches during development/testing without affecting release builds.

💡 Optional: Add debug assertion
     pub fn from_bytes_with_cache(
         private_key: [u8; PRIVATE_KEY_LENGTH],
         ed_public_key: CompressedEdwardsY,
         sign_bit: u8,
     ) -> Self {
         let secret = StaticSecret::from(scalar::clamp_integer(private_key));
+        #[cfg(debug_assertions)]
+        {
+            let (expected_ed, expected_sign) = Self::compute_ed_public_key(&secret);
+            debug_assert_eq!(ed_public_key, expected_ed, "Edwards public key mismatch");
+            debug_assert_eq!(sign_bit & 0b1000_0000_u8, expected_sign, "Sign bit mismatch");
+        }
         PrivateKey {
             secret,
             ed_public_key,
             // Mask to ensure only valid sign bit values (0x00 or 0x80)
             sign_bit: sign_bit & 0b1000_0000_u8,
         }
     }

Comment on lines +216 to +227
/// Stores the private key bytes with lazy-initialized cached values for XEdDSA signing.
/// The Edwards public key is computed on first signature, not at key creation.
/// This keeps key generation fast while subsequent signatures benefit from caching.
#[derive(Debug, Clone)]
enum PrivateKeyData {
DjbPrivateKey([u8; curve25519::PRIVATE_KEY_LENGTH]),
DjbPrivateKey {
/// The raw 32-byte private key
key: [u8; curve25519::PRIVATE_KEY_LENGTH],
/// Lazily-initialized Edwards cache (computed on first signature)
edwards_cache: OnceLock<EdwardsCacheData>,
},
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the actual code at lines 216-227 in curve.rs
sed -n '200,240p' wacore/libsignal/src/core/curve.rs | cat -n

Repository: jlucaso1/whatsapp-rust

Length of output: 1604


🏁 Script executed:

# Check the OnceLock import and usage
rg -n "use.*OnceLock|from.*OnceLock" wacore/libsignal/src/core/curve.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 94


🏁 Script executed:

# Check for any Clone implementations for PrivateKeyData
rg -n "impl.*Clone.*PrivateKeyData|impl Clone for PrivateKeyData" wacore/libsignal/src/core/curve.rs

Repository: jlucaso1/whatsapp-rust

Length of output: 48


🏁 Script executed:

# Check the full PrivateKeyData enum definition
rg -B5 -A20 "enum PrivateKeyData" wacore/libsignal/src/core/curve.rs | head -40

Repository: jlucaso1/whatsapp-rust

Length of output: 936


🏁 Script executed:

# Check Cargo.toml for Rust version requirements
rg -n "rust-version|edition" wacore/Cargo.toml

Repository: jlucaso1/whatsapp-rust

Length of output: 84


🏁 Script executed:

# Check if the code compiles by looking for build logs or test results
# First, check if there's a way to verify compilation
cd wacore && cargo check 2>&1 | head -100

Repository: jlucaso1/whatsapp-rust

Length of output: 175


🏁 Script executed:

# Also check if there are any feature flags or conditional attributes that might affect OnceLock
rg -n "feature|cfg" wacore/libsignal/src/core/curve.rs | head -20

Repository: jlucaso1/whatsapp-rust

Length of output: 48


OnceLock does not implement Clone, causing a compilation error.

PrivateKeyData cannot derive Clone because its OnceLock<EdwardsCacheData> field does not implement Clone. This will fail to compile with "the trait bound OnceLock<EdwardsCacheData>: Clone is not satisfied". Either remove the Clone derive or replace OnceLock with a synchronization primitive that implements Clone (e.g., Arc<Mutex<Option<EdwardsCacheData>>> or parking_lot::Once).

🤖 Prompt for AI Agents
In `@wacore/libsignal/src/core/curve.rs` around lines 216 - 227, PrivateKeyData
cannot derive Clone because OnceLock<EdwardsCacheData> isn't Clone; replace the
edwards_cache field with a cloneable sync wrapper such as
Arc<Mutex<Option<EdwardsCacheData>>> (e.g., edwards_cache:
Arc<Mutex<Option<EdwardsCacheData>>>), update any initialization to
Arc::new(Mutex::new(None)), and update code paths that lazily compute or read
the Edwards cache (where edwards_cache is accessed during signing) to lock the
mutex, check/set the Option, and return the cached value; this makes
PrivateKeyData::DjbPrivateKey cloneable while preserving the lazy-cache
semantics.

Comment on lines +99 to 110
/// Creates a PrivateKey from raw bytes WITHOUT computing the Edwards cache.
/// Use this for operations that don't need signatures (e.g., key agreement, public key derivation).
#[inline]
pub fn from_bytes_without_cache(private_key: [u8; PRIVATE_KEY_LENGTH]) -> Self {
let secret = StaticSecret::from(scalar::clamp_integer(private_key));
// Use dummy values - these should never be accessed for non-signature operations
PrivateKey {
secret,
ed_public_key: CompressedEdwardsY::default(),
sign_bit: 0,
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Silent failure risk if used for signing.

If a PrivateKey created via from_bytes_without_cache is accidentally used for calculate_signature, it will produce invalid signatures using dummy Edwards data. Consider adding a sentinel value or documentation warning.

💡 Suggested improvement

One option is to use a clearly invalid sentinel value that would cause signature verification to fail obviously, or add a comment at the calculate_signature call site. At minimum, strengthen the doc comment:

-    /// Creates a PrivateKey from raw bytes WITHOUT computing the Edwards cache.
-    /// Use this for operations that don't need signatures (e.g., key agreement, public key derivation).
+    /// Creates a PrivateKey from raw bytes WITHOUT computing the Edwards cache.
+    /// 
+    /// # Warning
+    /// Keys created with this function MUST NOT be used for `calculate_signature`.
+    /// Doing so will produce invalid signatures. Use this only for key agreement
+    /// and public key derivation operations.
🤖 Prompt for AI Agents
In `@wacore/libsignal/src/core/curve/curve25519.rs` around lines 99 - 110,
from_bytes_without_cache currently constructs a PrivateKey with dummy
ed_public_key and sign_bit=0 which can silently produce invalid signatures if
calculate_signature is called; update this by choosing a clear sentinel (e.g.,
an out-of-range sign_bit value or a reserved ed_public_key pattern) when
constructing the PrivateKey in from_bytes_without_cache and then make
calculate_signature check that sentinel and return an explicit error or panic
instead of producing a signature; update the doc comment on
from_bytes_without_cache to warn about non-signing use and reference the
sentinel so future maintainers see the contract (symbols:
from_bytes_without_cache, PrivateKey, ed_public_key, sign_bit,
calculate_signature).

@jlucaso1 jlucaso1 changed the title perf: Cache Edwards public key for XEdDSA signatures (~1.9x faster signing) perf: Cache Edwards public key for XEdDSA signatures (-27% signing, with lazy key gen) Jan 24, 2026
Optimizations:
1. get_receiver_chain_index(): Compare serialized bytes directly instead
   of deserializing PublicKey on each iteration. Avoids allocation and
   validation overhead per chain.

2. Remove unused get_receiver_chain() method that was cloning protobuf
   Chain structs unnecessarily.

3. Use finalize_sha256_array() in upload.rs and messages.rs for
   zero-allocation hash finalization instead of finalize() + conversion.
- Mask sign_bit in from_bytes_with_cache() to prevent invalid signatures
  from bad inputs (only 0x00 or 0x80 are valid)
- Add format validation in get_receiver_chain_index() before byte comparison
  to catch corrupted keys early instead of causing panics later
- Make KeyType::value() public to avoid duplicating the 0x05 constant
- Use KeyType::Djb.value() in session.rs validation for DRY
Add new_without_cache() function to curve25519::PrivateKey that
generates keys without computing the expensive Edwards public key.
@jlucaso1
jlucaso1 force-pushed the perf-experimental-cache-curve branch from 536d03d to 8fd2d31 Compare January 24, 2026 14:02

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@wacore/libsignal/src/core/curve.rs`:
- Around line 366-378: The comment is correct: KeyPair::generate currently calls
curve25519::PrivateKey::new_without_cache expecting no Edwards computation, but
new_without_cache still calls compute_ed_public_key; update the implementation
so it truly defers expensive scalar multiplication (or change the call site).
Specifically, modify curve25519::PrivateKey::new_without_cache to mirror
from_bytes_without_cache by NOT calling compute_ed_public_key and instead
initializing the edwards cache as an empty OnceLock/dummy placeholder, or
alternatively change KeyPair::generate to call from_bytes_without_cache (or
another clearly-named factory) so the API behavior matches the “lazy” claim
(referencing PrivateKey::new_without_cache,
PrivateKey::from_bytes_without_cache, and KeyPair::generate).
🧹 Nitpick comments (1)
wacore/libsignal/src/protocol/state/session.rs (1)

636-643: Avoid expect in the promotion path.

Even with a validated index, a panic here isn’t ideal; return a structured error instead to keep invariants defensive.
[warned_recommended_refactor? no] → use suggested tag.

♻️ Suggested fix
-            let state = self
-                .take_previous_session(index)
-                .expect("index was just validated");
+            let state = self
+                .take_previous_session(index)
+                .ok_or(InvalidSessionError("missing previous session"))?;
             self.promote_state(state);
As per coding guidelines, avoid `unwrap`/`expect` outside tests.

Comment on lines 366 to +378
pub fn generate<R: Rng + CryptoRng>(csprng: &mut R) -> Self {
let private_key = curve25519::PrivateKey::new(csprng);

let public_key = PublicKey::from(PublicKeyData::DjbPublicKey(
private_key.derive_public_key_bytes(),
));
let private_key = PrivateKey::from(PrivateKeyData::DjbPrivateKey(
private_key.private_key_bytes(),
));
// Generate key WITHOUT computing Edwards cache (lazy initialization).
// The Edwards point computation is deferred until first signature.
let temp = curve25519::PrivateKey::new_without_cache(csprng);
let key = temp.private_key_bytes();

let public_key =
PublicKey::from(PublicKeyData::DjbPublicKey(temp.derive_public_key_bytes()));
// Edwards cache will be computed lazily on first signature
let private_key = PrivateKey::from(PrivateKeyData::DjbPrivateKey {
key,
edwards_cache: OnceLock::new(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Lazy keygen isn’t actually lazy with current new_without_cache
KeyPair::generate assumes curve25519::PrivateKey::new_without_cache avoids the Edwards computation, but the current implementation (curve25519.rs lines 55–71) still calls compute_ed_public_key, so the expensive scalar multiplication still happens and the comment here is misleading. This defeats the “lazy init” goal for key generation.

Consider updating new_without_cache to skip Edwards computation and use dummy values (similar to from_bytes_without_cache), or rename/adjust the API so it’s not advertised as lazy.

🔧 Proposed fix in wacore/libsignal/src/core/curve25519.rs
 pub fn new_without_cache<R>(csprng: &mut R) -> Self
 where
     R: CryptoRng + Rng,
 {
     let mut bytes = [0u8; 32];
     csprng.fill_bytes(&mut bytes);
     bytes = scalar::clamp_integer(bytes);

     let secret = StaticSecret::from(bytes);
-    let (ed_public_key, sign_bit) = Self::compute_ed_public_key(&secret);
-    PrivateKey {
-        secret,
-        ed_public_key,
-        sign_bit,
-    }
+    // Avoid Edwards computation here; compute lazily on first signature.
+    PrivateKey {
+        secret,
+        ed_public_key: CompressedEdwardsY::default(),
+        sign_bit: 0,
+    }
 }
🤖 Prompt for AI Agents
In `@wacore/libsignal/src/core/curve.rs` around lines 366 - 378, The comment is
correct: KeyPair::generate currently calls
curve25519::PrivateKey::new_without_cache expecting no Edwards computation, but
new_without_cache still calls compute_ed_public_key; update the implementation
so it truly defers expensive scalar multiplication (or change the call site).
Specifically, modify curve25519::PrivateKey::new_without_cache to mirror
from_bytes_without_cache by NOT calling compute_ed_public_key and instead
initializing the edwards cache as an empty OnceLock/dummy placeholder, or
alternatively change KeyPair::generate to call from_bytes_without_cache (or
another clearly-named factory) so the API behavior matches the “lazy” claim
(referencing PrivateKey::new_without_cache,
PrivateKey::from_bytes_without_cache, and KeyPair::generate).

@jlucaso1
jlucaso1 merged commit 1abd9a4 into main Jan 24, 2026
7 of 8 checks passed
@jlucaso1
jlucaso1 deleted the perf-experimental-cache-curve branch January 25, 2026 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant